Repository navigation
fix(mcp): the stdio door answers get_record misses and describe_object readonly as the HTTP door does - #33175
Conversation
…ded RECORD_NOT_FOUND The stdio bridge answers a by-id miss with null, and get_record rendered that as a bare sentence, while the HTTP door's bridge throws the shared recordNotFoundError and answers the coded 404 envelope. get_record now throws that same value into its own catch, so both doors render one throw through one renderer. A missing id and an id the caller may not read reach the tool as the same empty read and answer alike. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
…ly on the declared field The stdio caller view served only the masked object, so a stored field whose expression names a field the caller cannot read lost its expression in the mask and was described readonly: false, while the engine still computes it. The caller view now registers a read that answers the served object beside the declaration it was projected from, from one read of the definition; describe_object and the objects resource judge readonly on the declared field, as the HTTP twin does, and build the answer from the served object only. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
…ring read Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
📓 Docs Drift CheckThis PR changes 2 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: What this run could not see
Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e542148584a037f950c1ccfb185e106eb8ca623a && git checkout e542148584a037f950c1ccfb185e106eb8ca623a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 588596af4320139479c7086dc8f300026123af35 532a02e508b59b19c5954b46dd125c2c7630d538 && git checkout -B drift-repro 588596af4320139479c7086dc8f300026123af35 && git merge --no-ff 532a02e508b59b19c5954b46dd125c2c7630d538
node scripts/docs-audit/affected-docs.mjs --json 588596af4320139479c7086dc8f300026123af35
|
…ion too The HTTP twin's writeNeverSetsField JSDoc and the unreleased describe_object readonly changeset still said the stdio door and the objects resource judge the masked field. They now say what holds: every transport judges readonly on the declared field. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
|
epic:#32897#1 · os-tesla · session_01L9qibzoApSyVPuia3NCrBY Contract reviewServed-tier: No contract face (no ① Derived judgments
② Semver levelClause-②: no. ③ Boundary flags
DELIBERATE CORRECTION confirmed,
Check-runs on this head: 55 — 35 success, 18 skipped (path filter), 2 failure, both Implemented-by: VERDICT: PASS Generated by Claude Code |
|
epic:#32897#1 · os-tesla · session_01L9qibzoApSyVPuia3NCrBY 维护者速读 · PR #33175 @
|
Requested by Tesla · project thread
Fixes #33030
Fixes #33156
Clause-②: no
Metadata-protocol: none
File surface (claim, as amended at review):
packages/mcp/src/stdio-data-bridge.ts,packages/mcp/src/mcp-server-runtime.ts,packages/mcp/src/mcp-http-tools.ts(theget_recordbranch only),packages/runtime/src/domains/mcp.ts(bridge member, and thewriteNeverSetsFieldJSDoc's last sentence), their tests, root.changeset/(including.changeset/32530-describe-object-readonly-computed.md, one sentence).维护者速读
/mcp门回答一致。get_record查不到记录(不存在或调用者无权看到)时回带编码的RECORD_NOT_FOUND/ 404,不再是一句无编码的文字;describe_object和objectstack://objects/{name}资源对「表达式被字段遮蔽隐藏的存储计算字段」回readonly: true。readonly: false会让代理去写一个引擎随后覆盖的字段,写了等于白写。packages/mcp/src/mcp-server-runtime.ts的readObjectWithDeclaration与describeServedObject(def, declared);packages/mcp/src/stdio-data-bridge.ts的callerObjectView(注册声明读取);packages/mcp/src/mcp-http-tools.ts的get_record空结果分支。pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2 src/stdio-data-bridge.not-found.test.ts src/stdio-data-bridge.object-schema-mask.test.ts,预期 42/42 通过。What changes
get_record(MCP stdio get_record answers a missing or hidden id with uncoded error text, while the HTTP door answers RECORD_NOT_FOUND 404 #33030). The stdio bridge'sgetanswers a by-id miss withnullby design, and the tool rendered that asRecord "ID" not found in "OBJECT"with no code. The null branch now throwsrecordNotFoundError(objectName, recordId)(@objectstack/core, the repo's one not-found envelope) into the tool's own catch, which renders it throughdataDoorErrorResult. That is the value the HTTP bridge'sgetthrows (protocol.getDatathroughcallData) and the renderer its catch uses, so both doors render one throw through one renderer:{ "error": { "code": "RECORD_NOT_FOUND", "message": "Record ID not found in OBJECT", "status": 404 } }. A missing id and an id the row filter hides reach the tool as the same empty read and answer the same bytes (no existence oracle).update_recordanddelete_recordwere already coded; pinned here, unchanged.describe_objectand the objects resource (mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156). The stdio caller view (callerObjectView) now registers, beside the view it returns, a read that answers the served object AND the declaration it was projected from, from ONE read of the definition (setDeclaredObjectRead, package-internal, keyed weakly by the view likesetRuntimeObjectView).readObjectWithDeclaration(view, name)asks it; a view that registered none masks nothing, so its object is its declaration.describeServedObject(def, declared = def)judgesreadonlywithwriteNeverSetsField(declared.fields[key]), exactly as the HTTP twin does, and builds every other member from the served object. The stdio bridge'sdescribeObjectandbuildObjectSchemaResourceboth read through it;plugin.tsalready wires the resource tocallerObjectView, so it needed no change.objectView.getObjectread beside the masked one would read the definition twice, and the two reads could disagree mid-write; the registered read answers both halves from one read and serves nothing extra. OnlywriteNeverSetsField's boolean is read off the declaration: the expression and the field it names stay out of the answer (pinned, and ablated).Tests
packages/mcp/src/stdio-data-bridge.not-found.test.ts, new top-level describe on the file's existing engine double (itsfindmade row-filtering, so no new double enters thecheck:engine-double-contractledger): over the long-lived serverstart()attaches stdio to, the realcreateStdioDataBridge:isErrorand the coded envelope;tools/callresult equals, deep-equal, the HTTP/mcpresult fromhandleHttpRequest(real Streamable HTTP transport) over a bridge whosegetthrowsrecordNotFoundError, the valuemcp-coded-refusal.test.tspins as the HTTP face's answer;update_recordanddelete_recordon both ids answer the same envelope.packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts, new top-level describe with the HTTP twin's fixture (marked_upis a stored expression overhidden_cost, which the member may not read):marked_upwithout its expression and nohidden_cost;describe_objectand the resource (wired asplugin.tswires it:callerObjectViewoverstdioObjectView) answer the full readonly map,marked_up: true, with nohidden_costtoken and norecord.expression text anywhere, and noexpressionmember onmarked_up;packages/runtime/src/domains/mcp-object-schema-mask.test.ts: a PARITY case on the existing real-layer harness (realObjectQLoverSqlDriver, realSecurityPlugin, the real/mcpentry) holding the same readonly map for the same member over the authored fields. Neither package depends on the other (the reason the twodescribeServedObjecttwins exist), so the doors' parity is that one literal, pinned on each side.Acceptance notes
errorResultWithCodefor the null branch. The PR throws into the catch, which renders throughdataDoorErrorResult, the renderer the HTTP door's own throw takes. ForRECORD_NOT_FOUNDthe two produce the same bytes today (mcp-coded-refusal.test.tspins both shapes); the catch path keeps them equal if the/datadoor ever adds a member (declaredCode,userMessage) to this code..changeset/32530-describe-object-readonly-computed.md(pending, landed with PR fix(mcp,runtime): describe_object serves readonly: true where a write never sets the value #33150) described the stdio door and the objects resource as judging the served field, which this PR makes false. Its two sentences "On the HTTP transport,readonlyis judged on the field as declared, … and the expression itself stays hidden. Over stdio and on the resource, which hold only the object as the caller is served it, the expression case answerstruewhere the caller is shown the expression." now read: "On every transport, the resource included,readonlyis judged on the field as declared, so a stored expression the caller is not shown (it names a field they cannot read) still answerstrue, and the expression itself stays hidden."check-empty-changeset.mjs --base origin/mainis red on that file by design (DELIBERATE CORRECTION class); confirmation is the seat's record.writeNeverSetsField's last sentence inpackages/runtime/src/domains/mcp.tssaid the stdio twin "has only the masked object to ask"; it now says the stdio bridge and the resource ask the declared field too (readObjectWithDeclaration). Prose only.objectstack://objects/{name}/records/{id}on a miss answers{"error":"Record \"ghost\" not found in \"task\""}with nocode/status(measured over the long-lived server at this head), while its two sibling resources answer{ error, code, status }and its own catch already adds them for a thrown coded refusal (mcp-server-runtime.ts,bridgeResources,record_by_id). Reported to the seat as a finding; not fixed here.Metadata-protocol: none, no narrowing arm);skill-md.tsrestates neither the not-found wording norreadonly.#33150merged before the mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156 half started (28a2f0f5, merged into this branch once);protocol-stdout.tsandcli/src/commands/serve.ts(fix(cli): with the stdio MCP transport on, a host plugin's console output while serving a call reaches stderr #33159) untouched.Verification
All readings at head
59ccee50(base28a2f0f5) unless named. Head532a02e5adds only the two prose edits above; its re-run gates are in the last block.Measured at origin/main first (both premises hold)
get_record(47f6c939): 3 failed | 5 passed. Missing id:content[0].text=Record "ghost" not found in "task"(not JSON); hidden id:Record "secret1" not found in "task"; PARITY: HTTP answered{"error":{"code":"RECORD_NOT_FOUND","message":"Record ghost not found in task","status":404}}.update_record/delete_recordalready passed (coded).28a2f0f5(after fix(mcp,runtime): describe_object serves readonly: true where a write never sets the value #33150): 2 failed | 32 passed; both doors served"marked_up": false, every other field matching.Package tests and typecheck
pnpm --filter @objectstack/mcp test(atc1b63360;59ccee50only re-shapes the not-found test's double) — Test Files 64 passed (64), Tests 1148 passed (1148),VERDICT command-exit 0stdio-data-bridge.not-found.test.tsat59ccee50— Tests 8 passed (8)mcp-object-schema-mask.test.ts(runtime) — Tests 73 passed (73)pnpm --filter @objectstack/mcp typecheckandpnpm --filter @objectstack/runtime typecheck— exit 0, each includingcheck:test-typecheckovertsconfig.test.jsonAblations (4, all red, all restored)
Every leg through
scripts/ablation-replace.mjs(anchor x1 → x0, blob changed, restored blob == HEAD,git diff HEADempty). The suites import the source, so nodist/leg.get_recordnull branch back to the bareerrorResultsentence → 3 failed (missing, hidden, PARITY).describeServedObjectjudging the served field (writeNeverSetsField(f)) → 2 failed (tool and resource).setDeclaredObjectReadregistration deleted → 2 failed (tool and resource).describeObjectbuilding the answer from the DECLARATION → 9 failed, the new "neither its expression nor hidden_cost is served" case and the case-table masking pins among them.Derived gates and lint
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 66 families, each run with stdin closed, exit code recorded before any pipe.--ranat59ccee50: 66 accounted, 65 run, 1 NOT-MEASURED.pnpm check:dual-build-cjs-loads— exit 3, PREREQUISITE NOT MET (needsdist/of packages outside this diff's closure). NOT MEASURED; CI runs it.node scripts/check-plugin-teardown-shape.mjs --self-testfirst exited 3 (pinned positive control absent from the shallow clone); after fetching621a4876, exit 0, 48 cases.pnpm check:engine-double-contractfirst exited 1 (a second double in the not-found file); the test now reuses the file's one double, exit 0.59ccee50, exit 0. All other families exit 0.pnpm check:type-check-debt— exit 0.pnpm lint— exit 0. No export renamed or removed.532a02e5(the two prose edits), the families that read those files, re-run:check-adr-0087-registration(--base,--self-test),check-changeset-no-major,check:changeset-gate-self-tests,check-closing-keyword-parity,check:doc-authoring,check:nul-bytes,check:issue-citationsand its script,check:adr-symbol-anchors, both comment-mask gates, both docs-audit scripts,check:closed-ceremony-text,check:keyed-text-bounds,check:dispatcher-error-vocabulary,check:route-envelope, andeslint packages/runtime/src/domains/mcp.ts— exit 0.check-empty-changeset.mjs --base origin/main— exit 1, the DELIBERATE CORRECTION refusal above, expected.Generated by Claude Code